Micron Document
🎖️GitЯра🎖️

Commit ae3e3d298c377902ea3b7ba6a58b7170927f4db1


Parents : 212eea9
Author : Lester Cheng <LesterCheng@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-16T21:12:52-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-17T02:12:52Z

fix(notifications): open node detail when tapping 'New Node Seen' notification (#5752)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com>

Changes
Diff

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
index 70e170dde4..740be0d226 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -239,6 +239,11 @@ class NodeManagerImpl(
title = getStringSuspend(Res.string.new_node_seen, next.user.short_name),
message = next.user.long_name,
category = Notification.Category.NodeEvent,
+ id = next.num,
+ // Path format must stay in sync with DEEP_LINK_BASE_URI + DeepLinkRouter
+ // in core/navigation (avoided as a Gradle dep here to keep core/data free
+ // of Compose Navigation libs).
+ deepLinkUri = "meshtastic://meshtastic/nodes/${next.num}",
),
)
}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
index 028eaa9ae6..fb55e3a2e0 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
@@ -25,6 +25,12 @@ data class Notification(
val isSilent: Boolean = false,
val group: String? = null,
val id: Int? = null,
+ /**
+ * Optional deep-link URI invoked when the user taps the notification. Platform implementations are responsible for
+ * converting this into the appropriate intent / activation action. When null, tapping the notification has no
+ * effect.
+ */
+ val deepLinkUri: String? = null,
) {
enum class Type {
None,

diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/app/MainActivity.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/app/MainActivity.kt
new file mode 100644
index 0000000000..f7e5e38ce7
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/app/MainActivity.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app
+
+import android.app.Activity
+
+/**
+ * Test-only stub for the real `MainActivity` in the `:androidApp` module. `AndroidNotificationManager` resolves the
+ * activity by FQN via `Class.forName(...)` to avoid pulling `:androidApp` into `:core:service` as a Gradle dependency.
+ * This stub lets unit tests exercise the deep-link `PendingIntent` construction path without that dependency.
+ */
+class MainActivity : Activity()

diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt
index d385c5a16f..09c0e47a7d 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt
@@ -117,6 +117,62 @@ class AndroidNotificationManagerTest {
assertDispatchesToChannel(manager, Notification.Category.Service, NotificationChannels.SERVICE)
}
+ @Test
+ fun `dispatch attaches deep-link PendingIntent when deepLinkUri is set`() {
+ registerStubMainActivity()
+ val manager = AndroidNotificationManager(context)
+ val deepLink = "meshtastic://meshtastic/nodes/1234"
+
+ manager.dispatch(
+ Notification(
+ title = "New node",
+ message = "Long Name",
+ category = Notification.Category.NodeEvent,
+ id = 1234,
+ deepLinkUri = deepLink,
+ ),
+ )
+
+ val posted = shadowOf(systemNotificationManager).allNotifications.last()
+ val pendingIntent =
+ requireNotNull(posted.contentIntent) { "Expected contentIntent to be set when deepLinkUri is provided" }
+ val shadowPendingIntent = shadowOf(pendingIntent)
+ val savedIntent = shadowPendingIntent.savedIntent
+ assertEquals(android.content.Intent.ACTION_VIEW, savedIntent.action)
+ assertEquals(deepLink, savedIntent.data?.toString())
+ assertEquals("org.meshtastic.app.MainActivity", savedIntent.component?.className)
+ }
+
+ @Test
+ fun `dispatch leaves contentIntent unset when deepLinkUri is null`() {
+ val manager = AndroidNotificationManager(context)
+
+ manager.dispatch(Notification(title = "Plain", message = "No tap", category = Notification.Category.NodeEvent))
+
+ val posted = shadowOf(systemNotificationManager).allNotifications.last()
+ assertNull(posted.contentIntent)
+ }
+
+ @Test
+ fun `dispatch uses provided notification id as system id`() {
+ val manager = AndroidNotificationManager(context)
+ val explicitId = 4242
+
+ manager.dispatch(
+ Notification(
+ title = "With id",
+ message = "explicit",
+ category = Notification.Category.NodeEvent,
+ id = explicitId,
+ ),
+ )
+
+ // Cancellation by the same id should remove the posted notification.
+ assertEquals(1, shadowOf(systemNotificationManager).allNotifications.size)
+ manager.cancel(explicitId)
+ assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size)
+ }
+
private fun assertDispatchesToChannel(
manager: AndroidNotificationManager,
category: Notification.Category,
@@ -137,6 +193,23 @@ class AndroidNotificationManagerTest {
)
}
+ /**
+ * Registers a stub `org.meshtastic.app.MainActivity` with the Robolectric `PackageManager` so that
+ * `TaskStackBuilder.addNextIntentWithParentStack` does not throw `NameNotFoundException` when resolving the
+ * activity that hosts deep-link intents. The real activity lives in `:androidApp`, which is intentionally not on
+ * `:core:service`'s test classpath.
+ */
+ private fun registerStubMainActivity() {
+ val componentName = android.content.ComponentName(context, "org.meshtastic.app.MainActivity")
+ val activityInfo =
+ android.content.pm.ActivityInfo().apply {
+ name = componentName.className
+ packageName = componentName.packageName
+ exported = true
+ }
+ shadowOf(context.packageManager).addOrUpdateActivity(activityInfo)
+ }
+
private fun clearManagedChannels() {
val channelIds =
NotificationChannels.LEGACY_CATEGORY_IDS +

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
index 1d01c23571..f8f02a6ead 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
@@ -17,10 +17,14 @@
package org.meshtastic.core.service
import android.app.NotificationChannel
+import android.app.PendingIntent
+import android.app.TaskStackBuilder
import android.content.Context
+import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
+import androidx.core.net.toUri
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
@@ -108,6 +112,7 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
override fun dispatch(notification: Notification) {
ensureChannelsInitialized()
+ val id = notification.id ?: notification.hashCode()
val builder =
NotificationCompat.Builder(context, notification.category.channelConfig().id)
.setContentTitle(notification.title)
@@ -122,10 +127,29 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
builder.setPriority(NotificationCompat.PRIORITY_HIGH)
}
- val id = notification.id ?: notification.hashCode()
+ notification.deepLinkUri?.let { uri -> builder.setContentIntent(createDeepLinkPendingIntent(uri, id)) }
+
notificationManager.notify(id, builder.build())
}
+ /**
+ * Builds a [PendingIntent] that launches [MainActivity] with the given deep-link URI as [Intent.ACTION_VIEW], so
+ * the existing deep-link plumbing (`UIViewModel.handleDeepLink` → `DeepLinkRouter` → `MultiBackstack`) can
+ * synthesize the proper backstack and surface the target screen.
+ *
+ * Uses [Class.forName] to avoid pulling the `:androidApp` module into `:core:service` as a Gradle dep.
+ */
+ private fun createDeepLinkPendingIntent(uri: String, requestCode: Int): PendingIntent {
+ val deepLinkIntent =
+ Intent(Intent.ACTION_VIEW, uri.toUri(), context, Class.forName(MAIN_ACTIVITY_CLASS)).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ return TaskStackBuilder.create(context).run {
+ addNextIntentWithParentStack(deepLinkIntent)
+ getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)!!
+ }
+ }
+
override fun cancel(id: Int) {
notificationManager.cancel(id)
}
@@ -133,4 +157,12 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
override fun cancelAll() {
notificationManager.cancelAll()
}
+
+ private companion object {
+ /**
+ * Fully-qualified name of the host activity that handles `meshtastic://` deep-link intents. Kept as a string to
+ * avoid creating a module dependency from `:core:service` back onto `:androidApp`.
+ */
+ const val MAIN_ACTIVITY_CLASS = "org.meshtastic.app.MainActivity"
+ }
}

Served by rngit 1.5.0 - Generated in 0.1s